<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>拟合 · 过拟合 · 欠拟合｜交互演示（分类 / 回归）</title>
  <style>
    :root {
      --bg: #0b1324;
      /* 背景 */
      --panel: #111a32;
      /* 面板 */
      --muted: #7d8db5;
      /* 次要文字 */
      --text: #e8eeff;
      /* 主文字 */
      --brand: #5ad6ff;
      /* 品牌高亮（训练） */
      --ok: #46d082;
      /* 正向 */
      --warn: #ffb84d;
      /* 验证 */
      --bad: #ff6b6b;
      /* 错误 */
      --accent: #c0a6ff;
      /* 测试 */
    }

    html,
    body {
      height: 100%
    }

    body {
      margin: 0;
      background: linear-gradient(160deg, #0b1324, #0c1a3a 45%, #0b1324);
      color: var(--text);
      font: 16px/1.55 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif
    }

    header {
      position: sticky;
      top: 0;
      z-index: 5;
      background: rgba(10, 18, 40, .75);
      backdrop-filter: blur(6px);
      border-bottom: 1px solid #1d2a52
    }

    .wrap {
      max-width: 1100px;
      margin: 0 auto;
      padding: 14px 18px
    }

    h1 {
      margin: 0;
      display: flex;
      gap: .6rem;
      align-items: center;
      font-size: 22px
    }

    .badge {
      font-size: 12px;
      padding: .15rem .55rem;
      border-radius: 999px;
      background: linear-gradient(45deg, var(--brand), var(--accent));
      color: #00192a
    }

    main {
      max-width: 1100px;
      margin: 14px auto;
      padding: 0 18px 28px
    }

    .tabs {
      display: flex;
      gap: 8px;
      flex-wrap: wrap;
      margin-top: 8px
    }

    .tabs button {
      appearance: none;
      border: 1px solid #283769;
      background: #0e1c3b;
      color: var(--text);
      padding: .5rem .8rem;
      border-radius: 11px;
      cursor: pointer;
      transition: .2s
    }

    .tabs button[aria-selected="true"],
    .tabs button:hover {
      border-color: var(--brand);
      box-shadow: 0 0 0 2px rgba(90, 214, 255, .15) inset
    }

    section.panel {
      display: none;
      background: linear-gradient(180deg, #0e1730, #0a1226);
      border: 1px solid #1d2a52;
      border-radius: 14px;
      padding: 16px;
      margin-top: 12px
    }

    section.panel.active {
      display: block
    }

    .grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
      gap: 14px
    }

    .card {
      background: radial-gradient(120% 120% at 0% 0%, #0f2352, #0b1736 55%, #0a1531);
      border: 1px solid #1d2a52;
      border-radius: 14px;
      padding: 14px;
      box-shadow: 0 6px 20px rgba(0, 0, 0, .25)
    }

    .card h3 {
      margin: .2rem 0 .4rem;
      font-size: 18px
    }

    .card p {
      margin: .25rem 0;
      color: #cfe0ff
    }

    .pill {
      display: inline-block;
      border: 1px solid #334a86;
      border-radius: 999px;
      padding: .15rem .5rem;
      font-size: 12px;
      color: #b9c8ff;
      margin-right: 6px
    }

    .tool {
      display: flex;
      gap: 12px;
      flex-wrap: wrap;
      align-items: center;
      margin: 8px 0 10px
    }

    .tool-row {
      margin-top: 8px;
      display: block;
    }

    .tool label {
      font-size: 14px;
      color: #c9d7ff
    }

    .btn {
      appearance: none;
      border: 1px solid #355099;
      background: #11214a;
      color: #eaf2ff;
      padding: .45rem .7rem;
      border-radius: 10px;
      cursor: pointer
    }

    .btn:hover {
      border-color: var(--brand)
    }

    input[type="range"] {
      accent-color: var(--brand)
    }

    canvas {
      background: #0a142c;
      border: 1px solid #1d2a52;
      border-radius: 10px;
      max-width: 100%
    }

    .legend {
      font-size: 13.5px;
      color: #bcd0ff
    }

    .stats {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
      gap: 10px
    }

    .stat {
      background: #0f1e3e;
      border: 1px solid #2d3e74;
      border-radius: 10px;
      padding: 10px
    }

    .stat .t {
      font-size: 12px;
      color: #9bb2ff
    }

    .stat .n {
      font-weight: 700;
      font-size: 20px;
      color: var(--brand)
    }

    .stat.good .n {
      color: var(--ok)
    }

    .stat.warn .n {
      color: var(--warn)
    }

    .stat.bad .n {
      color: var(--bad)
    }

    details {
      background: #0e1d3f;
      border: 1px solid #2a3e79;
      border-radius: 10px;
      padding: 10px
    }

    .two {
      display: grid;
      grid-template-columns: 1.15fr .85fr;
      gap: 16px
    }

    @media (max-width:980px) {
      .two {
        grid-template-columns: 1fr
      }
    }

    /* 小测验 */
    .quiz {
      margin-top: 10px
    }

    .opt {
      padding: 10px;
      border: 1px solid #2d3e74;
      border-radius: 8px;
      background: #0f1e3e;
      cursor: pointer;
      margin: 8px 0
    }

    .opt:hover {
      border-color: var(--brand)
    }

    .opt.correct {
      background: rgba(70, 208, 130, .18);
      border-color: rgba(70, 208, 130, .8)
    }

    .opt.wrong {
      background: rgba(255, 107, 107, .18);
      border-color: rgba(255, 107, 107, .8)
    }

    /* 迷你图例标记 */
    .key {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      margin-right: 10px
    }

    .dot {
      width: 10px;
      height: 10px;
      border-radius: 50%
    }

    .k-tr {
      background: var(--brand)
    }

    .k-va {
      background: var(--warn)
    }

    .k-te {
      background: var(--accent)
    }

    .stat-card {
      background: #0f1e3e;
      border: 1px solid #2d3e74;
      border-radius: 8px;
      padding: 15px;
      text-align: center;
      min-width: 80px;
    }

    .stat-number {
      font-size: 24px;
      font-weight: bold;
      color: var(--brand);
    }

    .stat-label {
      font-size: 14px;
      color: #b7c6ff;
    }


    /* 标注文字贴片 */
    .tag {
      position: absolute;
      padding: .15rem .4rem;
      border: 1px solid #2d3e74;
      background: #0f1e3e;
      border-radius: 6px;
      font-size: 12px;
      color: #bcd0ff
    }
  </style>
</head>

<body>
  <header>
    <div class="wrap">
      <h1>拟合 · 过拟合 · 欠拟合 <span class="badge">拟合</span></h1>
      <div class="tabs" role="tablist">
        <button role="tab" aria-controls="concept" id="t-concept" aria-selected="true">① 概念与损失</button>
        <button role="tab" aria-controls="cls" id="t-cls" aria-selected="false">② 分类：kNN + LogLoss</button>
        <button role="tab" aria-controls="reg" id="t-reg" aria-selected="false">③ 回归：多项式 + RMSE</button>
        <button role="tab" aria-controls="quiz" id="t-quiz" aria-selected="false">④ 小测验</button>
      </div>
    </div>
  </header>

  <main>
    <section id="concept" class="panel active" role="tabpanel" aria-labelledby="t-concept">
      <div class="grid">
        <article class="card">
          <h3>什么是“拟合”？</h3>
          <p>给定训练数据，选择模型并估计参数，使其能在<strong>看不见的新样本</strong>上也有良好表现（泛化）。</p>
          <p class="legend"><span class="pill">风险：欠拟合（模型太简单）/过拟合（模型太复杂）</span></p>
        </article>
        <article class="card">
          <h3>三数据集划分</h3>
          <p>本页统一采用 <b>60% 训练</b> / <b>20% 验证</b> / <b>20% 测试</b> 划分：</p>
          <p class="legend">训练：学参数； 验证：调超参与早停； 测试：最后一次查看，用于真实泛化评估。</p>
        </article>
        <article class="card">
          <h3>损失函数（Loss）</h3>
          <p><b>二分类对数损失（LogLoss）</b>：给出正类概率 p，标签 y∈{0,1}</p>
          <p class="legend">LogLoss = −(1/N)∑[ y·log p + (1−y)·log(1−p) ]</p>
          <p><b>回归 RMSE</b>：</p>
          <p class="legend">RMSE = √((1/N)∑(ŷ−y)²)</p>
        </article>
        <article class="card">
          <h3>如何一眼看出过/欠拟合？</h3>
          <ul class="legend">
            <li>复杂度↑（kNN 的 <b>k</b>↓；多项式的<b>阶数</b>↑）⇒ 训练损失通常下降；</li>
            <li>验证/测试损失先降后升（U 形）：<b>拐点左侧</b>偏欠拟合，<b>拐点右侧</b>偏过拟合。</li>
          </ul>
        </article>
      </div>
    </section>

    <section id="cls" class="panel" role="tabpanel" aria-labelledby="t-cls">
      <div class="two">
        <div>
          <div class="tool">
            <label>样本数：<input type="range" id="c-n" min="60" max="600" value="200"> <span id="c-nv">200</span></label>
            <label>重叠/噪声：<input type="range" id="c-noise" min="0" max="1" step="0.02" value="0.30"> <span
                id="c-noisev">0.30</span></label>
            <label>复杂度（kNN 的 k）：<input type="range" id="c-k" min="1" max="41" step="2" value="9"> <span
                id="c-kv">9</span></label>
            <button class="btn" id="c-new">重新生成</button>
            <div class="tool-row">
              <span class="legend">一键示例：</span>
              <button class="btn" id="c-under">欠拟合</button>
              <button class="btn" id="c-just">拟合得当</button>
              <button class="btn" id="c-over">过拟合</button>
            </div>
          </div>
          <canvas id="c-canvas" width="620" height="420" aria-label="分类 · 决策边界"></canvas>
          <div class="legend" style="margin-top:6px">
            <span class="key"><span class="dot k-tr"></span>训练</span>
            <span class="key"><span class="dot k-va"></span>验证</span>
            <span class="key"><span class="dot k-te"></span>测试</span>
          </div>
          <canvas id="c-lossplot" width="620" height="190" aria-label="分类：损失-复杂度曲线" style="margin-top:10px"></canvas>
          <p class="legend">下图为 <b>LogLoss</b> 随 k 变化（k 越小容量越大）。竖线：<b>当前 k</b>；星标：<b>验证最优 k*</b>；灰色遮罩：左欠拟合区 / 右过拟合区。</p>
        </div>
        <div>
          <div class="stats">
            <div class="stat" id="c-ltr">
              <div class="t">训练 LogLoss</div>
              <div class="n" id="c-train-loss">—</div>
            </div>
            <div class="stat" id="c-lva">
              <div class="t">验证 LogLoss</div>
              <div class="n" id="c-val-loss">—</div>
            </div>
            <div class="stat" id="c-lte">
              <div class="t">测试 LogLoss</div>
              <div class="n" id="c-test-loss">—</div>
            </div>
            <div class="stat" id="c-diagnose">
              <div class="t">诊断</div>
              <div class="n" id="c-dia-text">—</div>
            </div>
          </div>
          <details style="margin-top:12px">
            <summary>原理（kNN 概率 + LogLoss）</summary>
            <p class="legend">对点 x，取最近 k 邻居，正类概率 p=邻居中正类比例。训练集仅作邻居库；在验证/测试集上计算 LogLoss，以观察泛化。</p>
          </details>
          <div class="card" style="margin-top:10px">
            <h3 style="margin-top:0">解释</h3>
            <p class="legend" id="c-explain">—</p>
          </div>
        </div>
      </div>
    </section>

    <section id="reg" class="panel" role="tabpanel" aria-labelledby="t-reg">
      <div class="two">
        <div>
          <div class="tool">
            <label>样本数：<input type="range" id="r-n" min="30" max="300" value="120"> <span id="r-nv">120</span></label>
            <label>多项式阶数：<input type="range" id="r-deg" min="0" max="16" value="3"> <span id="r-degv">3</span></label>
            <label>正则 λ：<input type="range" id="r-lam" min="0" max="1" step="0.02" value="0.00"> <span
                id="r-lamv">0.00</span></label>
            <label>噪声：<input type="range" id="r-noise" min="0" max="0.8" step="0.02" value="0.25"> <span
                id="r-noisev">0.25</span></label>
            <button class="btn" id="r-new">重新生成</button>
            <br><span class="legend">一键示例：</span>
            <button class="btn" id="r-under">欠拟合</button>
            <button class="btn" id="r-just">拟合得当</button>
            <button class="btn" id="r-over">过拟合</button>
          </div>
          <canvas id="r-canvas" width="620" height="420" aria-label="回归 · 多项式拟合"></canvas>
          <div class="legend" style="margin-top:6px">
            <span class="key"><span class="dot k-tr"></span>训练</span>
            <span class="key"><span class="dot k-va"></span>验证</span>
            <span class="key"><span class="dot k-te"></span>测试</span>
          </div>
          <canvas id="r-lossplot" width="620" height="190" aria-label="回归：损失-复杂度曲线" style="margin-top:10px"></canvas>
          <p class="legend">下图为 <b>RMSE</b> 随阶数 d 变化。竖线：<b>当前 d</b>；星标：<b>验证最优 d*</b>；灰色遮罩：左欠拟合区 / 右过拟合区。</p>
        </div>
        <div>
          <div class="stats">
            <div class="stat" id="r-ltr">
              <div class="t">训练 RMSE</div>
              <div class="n" id="r-train-loss">—</div>
            </div>
            <div class="stat" id="r-lva">
              <div class="t">验证 RMSE</div>
              <div class="n" id="r-val-loss">—</div>
            </div>
            <div class="stat" id="r-lte">
              <div class="t">测试 RMSE</div>
              <div class="n" id="r-test-loss">—</div>
            </div>
            <div class="stat" id="r-diagnose">
              <div class="t">诊断</div>
              <div class="n" id="r-dia-text">—</div>
            </div>
          </div>
          <details style="margin-top:12px">
            <summary>原理（最小二乘 + L2 正则）</summary>
            <p class="legend">将 x∈[0,1] 映射为 [1,x,x²,...,x^d]，求解 (XᵀX+λI)w=Xᵀy。用 60/20/20 训练/验证/测试划分计算 RMSE。</p>
          </details>
          <div class="card" style="margin-top:10px">
            <h3 style="margin-top:0">解释</h3>
            <p class="legend" id="r-explain">—</p>
          </div>
        </div>
      </div>
    </section>

    <section id="quiz" class="panel" role="tabpanel" aria-labelledby="t-quiz">
      <article class="card">
        <h3>🧠 快速小测</h3>
        <div class="quiz" id="qbox"></div>
        <button class="btn" id="nextQ" style="display:none">下一题</button>
        <p class="legend" id="qhint"></p>
        <div class="stats">
          <div class="stat-card">
            <div class="stat-number" id="quizCorrect">0</div>
            <div class="stat-label">正确</div>
          </div>
          <div class="stat-card">
            <div class="stat-number" id="quizTotal">0</div>
            <div class="stat-label">已答</div>
          </div>
          <div class="stat-card">
            <div class="stat-number" id="quizAcc">0%</div>
            <div class="stat-label">正确率</div>
          </div>
        </div>
      </article>
    </section>
  </main>

  <script>
    /************** 通用 Tab 切换 **************/
    const panels = [...document.querySelectorAll('section.panel')];
    document.querySelectorAll('.tabs [role="tab"]').forEach(b => {
      b.addEventListener('click', () => {
        document.querySelectorAll('.tabs [role="tab"]').forEach(x => x.setAttribute('aria-selected', 'false'));
        b.setAttribute('aria-selected', 'true');
        panels.forEach(p => p.classList.remove('active'));
        document.getElementById(b.id.replace('t-', '')).classList.add('active');
      });
    });

    /************** 工具函数 **************/
    const rnd = (a, b) => a + Math.random() * (b - a);
    const randn = (() => { // Box-Muller
      let spare = null;
      return () => {
        if (spare !== null) { const v = spare; spare = null; return v; }
        let u = 0, v = 0; while (u === 0) u = Math.random(); while (v === 0) v = Math.random();
        const r = Math.sqrt(-2 * Math.log(u)), th = 2 * Math.PI * v; spare = r * Math.sin(th); return r * Math.cos(th);
      };
    })();
    function shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0;[a[i], a[j]] = [a[j], a[i]] } return a }
    function split3Index(n, p1 = 0.6, p2 = 0.2) { // 返回 [train,val,test] 索引数组
      const ix = [...Array(n).keys()]; shuffle(ix);
      const n1 = Math.floor(n * p1), n2 = Math.floor(n * p2);
      return [ix.slice(0, n1), ix.slice(n1, n1 + n2), ix.slice(n1 + n2)];
    }

    /************** 绘图通用：折线图 + 标注 **************/
    function drawLossLines(canvas, xs, ytr, yva, yte, xlabelFn, currentIndex, minValIndex) {
      const ctx = canvas.getContext('2d'); const w = canvas.width, h = canvas.height; ctx.clearRect(0, 0, w, h);
      const pad = { l: 44, r: 14, t: 12, b: 30 };
      const X = i => pad.l + (i) * (w - pad.l - pad.r) / Math.max(1, (xs.length - 1));
      const allY = [...ytr, ...yva, ...yte].filter(Number.isFinite);
      const yMin = Math.min(...allY), yMax = Math.max(...allY);
      const Y = y => pad.t + (yMax - y) * (h - pad.t - pad.b) / Math.max(1e-9, (yMax - yMin));
      // 边框
      ctx.strokeStyle = '#152550'; ctx.lineWidth = 1; ctx.strokeRect(pad.l, pad.t, w - pad.l - pad.r, h - pad.t - pad.b);
      // y 轴刻度
      ctx.fillStyle = '#9bb2ff'; ctx.font = '12px system-ui';
      for (let i = 0; i <= 4; i++) { const yy = yMin + (yMax - yMin) * i / 4; const py = Y(yy); ctx.beginPath(); ctx.moveTo(pad.l, py); ctx.lineTo(w - pad.r, py); ctx.strokeStyle = '#152550'; ctx.stroke(); ctx.fillText(yy.toFixed(2), 8, py + 4); }
      // x 轴刻度
      const tickCnt = Math.min(8, xs.length);
      for (let i = 0; i < tickCnt; i++) { const j = Math.round(i * (xs.length - 1) / (tickCnt - 1)); const x = X(j); const label = xlabelFn ? xlabelFn(xs[j]) : xs[j]; ctx.fillText(label, x - 8, h - 8); }
      // 阴影：欠拟合/过拟合区（以验证最优为界）
      if (Number.isFinite(minValIndex)) {
        ctx.fillStyle = 'rgba(255,255,255,0.05)';
        ctx.fillRect(X(0), pad.t, X(minValIndex) - X(0), h - pad.t - pad.b); // 欠拟合区
        ctx.fillRect(X(minValIndex), pad.t, X(xs.length - 1) - X(minValIndex), h - pad.t - pad.b); // 过拟合区
        // 标签
        ctx.fillStyle = '#9bb2ff'; ctx.fillText('欠拟合区', X(1) + 6, pad.t + 14);
        ctx.fillText('过拟合区', X(xs.length - 2) - 40, pad.t + 14);
      }
      // 画线函数
      function line(arr, color) { ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2; let first = true; for (let i = 0; i < xs.length; i++) { const v = arr[i]; if (!Number.isFinite(v)) continue; const px = X(i), py = Y(v); if (first) { ctx.moveTo(px, py); first = false; } else ctx.lineTo(px, py); } ctx.stroke(); }
      line(ytr, '#5ad6ff');
      line(yva, '#ffb84d');
      line(yte, '#c0a6ff');
      // 最优验证点星标
      if (Number.isFinite(minValIndex)) {
        const vx = X(minValIndex), vy = Y(yva[minValIndex]);
        ctx.fillStyle = '#ffb84d'; ctx.beginPath(); ctx.moveTo(vx, vy - 6); ctx.lineTo(vx + 4, vy + 5); ctx.lineTo(vx - 6, vy - 1); ctx.lineTo(vx + 6, vy - 1); ctx.lineTo(vx - 4, vy + 5); ctx.closePath(); ctx.fill();
      }
      // 当前模型竖线
      if (Number.isFinite(currentIndex)) {
        const vx = X(currentIndex); ctx.setLineDash([6, 6]); ctx.strokeStyle = '#8aa8ff'; ctx.beginPath(); ctx.moveTo(vx, pad.t); ctx.lineTo(vx, h - pad.b); ctx.stroke(); ctx.setLineDash([]);
      }
      // 图例（左上角）
      ctx.fillStyle = '#bcd0ff'; ctx.fillText('— 训练', pad.l + 6, pad.t + 16);
      ctx.fillStyle = '#bcd0ff'; ctx.fillText('— 验证', pad.l + 72, pad.t + 16);
      ctx.fillStyle = '#bcd0ff'; ctx.fillText('— 测试', pad.l + 132, pad.t + 16);
    }

    /************** ② 分类：kNN + LogLoss **************/
    const cN = document.getElementById('c-n');
    const cNoise = document.getElementById('c-noise');
    const cK = document.getElementById('c-k');
    const cNv = document.getElementById('c-nv');
    const cNoisev = document.getElementById('c-noisev');
    const cKv = document.getElementById('c-kv');
    const cNew = document.getElementById('c-new');
    const cUnder = document.getElementById('c-under');
    const cJust = document.getElementById('c-just');
    const cOver = document.getElementById('c-over');
    const cCanvas = document.getElementById('c-canvas');
    const cLossPlot = document.getElementById('c-lossplot');
    const cc = cCanvas.getContext('2d');

    cN.oninput = () => { cNv.textContent = cN.value; genC() };
    cNoise.oninput = () => { cNoisev.textContent = cNoise.value; genC() };
    cK.oninput = () => { cKv.textContent = cK.value; drawC() };
    cNew.onclick = () => genC();

    cUnder.onclick = () => { cK.value = 41; cKv.textContent = '41'; drawC(); };
    cJust.onclick = () => { cK.value = 9; cKv.textContent = '9'; drawC(); };
    cOver.onclick = () => { cK.value = 1; cKv.textContent = '1'; drawC(); };

    let C_DATA = [], C_TRAIN = [], C_VAL = [], C_TEST = [];

    function genC() {
      const n = +cN.value; const noise = +cNoise.value; C_DATA = [];
      const w = cCanvas.width, h = cCanvas.height;
      // 两个高斯簇，间距受 noise 影响（越大越重叠）
      const d = 150 * (1 - noise * 0.75); // 中心间距
      const cx1 = w * 0.36, cy1 = h * 0.62, cx2 = cx1 + d, cy2 = h * 0.35;
      for (let i = 0; i < n; i++) {
        const cls = Math.random() < 0.5 ? 0 : 1; const s = 26 + noise * 40;
        const x = (cls ? cx2 : cx1) + randn() * s; const y = (cls ? cy2 : cy1) + randn() * s;
        C_DATA.push({ x, y, ytrue: cls });
      }
      const [tr, val, te] = split3Index(C_DATA.length, 0.6, 0.2);
      C_TRAIN = tr.map(i => C_DATA[i]); C_VAL = val.map(i => C_DATA[i]); C_TEST = te.map(i => C_DATA[i]);
      drawC();
    }

    function knnProb(pt, data, k) {
      const dists = data.map(p => ({ p, d: (p.x - pt.x) * (p.x - pt.x) + (p.y - pt.y) * (p.y - pt.y) })).sort((a, b) => a.d - b.d);
      const kk = Math.min(k, dists.length); let c1 = 0; for (let i = 0; i < kk; i++) { if (dists[i].p.ytrue) c1++; }
      return c1 / Math.max(1, kk);
    }
    const eps = 1e-9;
    function logloss(dataTrain, dataEval, k) {
      let s = 0; for (const p of dataEval) { const pr = Math.min(1 - eps, Math.max(eps, knnProb(p, dataTrain, k))); const y = p.ytrue; s += -(y * Math.log(pr) + (1 - y) * Math.log(1 - pr)); } return s / dataEval.length;
    }

    function diagnoseCls(ltr, lva, lte) {
      const gapV = lva - ltr, gapT = lte - ltr;
      if (ltr > 0.55 && lva > 0.55 && lte > 0.55) return { text: '欠拟合：三组损失都高（模型太简单）', cls: 'warn' };
      if (gapV > 0.20 && gapT > 0.20) return { text: '过拟合：训练低、验证/测试高（记住了噪声）', cls: 'bad' };
      if (lva < 0.25 && Math.abs(gapV) < 0.10 && Math.abs(lte - lva) < 0.10) return { text: '拟合得当：训练与验证/测试均低且接近', cls: 'good' };
      return { text: gapV > 0.20 ? '可能过拟合：请增大 k 或加正则' : '偏欠拟合：请减小 k 或加特征', cls: gapV > 0.20 ? 'bad' : 'warn' };
    }

    function setExplain(id, t) { document.getElementById(id).textContent = t; }

    function drawC() {
      const w = cCanvas.width, h = cCanvas.height; cc.clearRect(0, 0, w, h);
      // 背景网格
      cc.strokeStyle = '#152550'; cc.lineWidth = 1;
      for (let x = 20; x < w; x += 20) { cc.beginPath(); cc.moveTo(x, 0); cc.lineTo(x, h); cc.stroke() }
      for (let y = 20; y < h; y += 20) { cc.beginPath(); cc.moveTo(0, y); cc.lineTo(w, y); cc.stroke() }
      // 决策边界（粗网格采样）
      const k = +cK.value;
      const step = 12; // 每 12px 采样
      for (let gx = 0; gx < w; gx += step) {
        for (let gy = 0; gy < h; gy += step) {
          const pred = knnProb({ x: gx + 6, y: gy + 6 }, C_TRAIN, k) > 0.5 ? 1 : 0;
          cc.fillStyle = pred ? 'rgba(255,184,77,0.08)' : 'rgba(90,214,255,0.08)';
          cc.fillRect(gx, gy, step, step);
        }
      }
      // 画样本：训练实心、验证空心、测试三角
      function dotFilled(p, fill, stroke) { cc.beginPath(); cc.fillStyle = fill; cc.strokeStyle = stroke; cc.lineWidth = 1.5; cc.arc(p.x, p.y, 5, 0, Math.PI * 2); cc.fill(); cc.stroke(); }
      function dotCircle(p, stroke) { cc.beginPath(); cc.strokeStyle = stroke; cc.lineWidth = 1.5; cc.arc(p.x, p.y, 6, 0, Math.PI * 2); cc.stroke(); }
      function dotTri(p, stroke) { cc.beginPath(); cc.strokeStyle = stroke; cc.lineWidth = 1.5; cc.moveTo(p.x, p.y - 6); cc.lineTo(p.x - 5, p.y + 4); cc.lineTo(p.x + 5, p.y + 4); cc.closePath(); cc.stroke(); }
      C_TRAIN.forEach(p => dotFilled(p, p.ytrue ? '#ffb84d' : '#5ad6ff', '#00000088'));
      C_VAL.forEach(p => dotCircle(p, '#ffb84d'));
      C_TEST.forEach(p => dotTri(p, '#c0a6ff'));

      // 损失数值
      const ltr = logloss(C_TRAIN, C_TRAIN, k), lva = logloss(C_TRAIN, C_VAL, k), lte = logloss(C_TRAIN, C_TEST, k);
      document.getElementById('c-train-loss').textContent = ltr.toFixed(3);
      document.getElementById('c-val-loss').textContent = lva.toFixed(3);
      document.getElementById('c-test-loss').textContent = lte.toFixed(3);
      const d = diagnoseCls(ltr, lva, lte); const box = document.getElementById('c-diagnose'); box.classList.remove('good', 'warn', 'bad'); box.classList.add(d.cls); document.getElementById('c-dia-text').textContent = d.text;
      setExplain('c-explain', `当 k=${k} 时：训练 ${ltr.toFixed(3)}，验证 ${lva.toFixed(3)}，测试 ${lte.toFixed(3)}。${d.text}`);

      // 损失-复杂度曲线
      const Ks = [...Array(21).keys()].map(i => 1 + i * 2); // 1..41 奇数
      const ltrA = [], lvaA = [], lteA = []; for (const kk of Ks) { ltrA.push(logloss(C_TRAIN, C_TRAIN, kk)); lvaA.push(logloss(C_TRAIN, C_VAL, kk)); lteA.push(logloss(C_TRAIN, C_TEST, kk)); }
      const minIdx = lvaA.indexOf(Math.min(...lvaA));
      const curIdx = Ks.indexOf(k);
      drawLossLines(cLossPlot, Ks, ltrA, lvaA, lteA, x => x, curIdx, minIdx);
    }

    genC();

    /************** ③ 回归：多项式 + L2 正则 **************/
    const rN = document.getElementById('r-n');
    const rDeg = document.getElementById('r-deg');
    const rLam = document.getElementById('r-lam');
    const rNoise = document.getElementById('r-noise');
    const rNv = document.getElementById('r-nv');
    const rDegv = document.getElementById('r-degv');
    const rLamv = document.getElementById('r-lamv');
    const rNoisev = document.getElementById('r-noisev');
    const rNew = document.getElementById('r-new');
    const rUnder = document.getElementById('r-under');
    const rJust = document.getElementById('r-just');
    const rOver = document.getElementById('r-over');
    const rCanvas = document.getElementById('r-canvas');
    const rLossPlot = document.getElementById('r-lossplot');
    const rc = rCanvas.getContext('2d');

    rN.oninput = () => { rNv.textContent = rN.value; genR() }
    rDeg.oninput = () => { rDegv.textContent = rDeg.value; drawR() }
    rLam.oninput = () => { rLamv.textContent = Number(rLam.value).toFixed(2); drawR() }
    rNoise.oninput = () => { rNoisev.textContent = rNoise.value; genR() }
    rNew.onclick = () => genR();

    rUnder.onclick = () => { rDeg.value = 0; rDegv.textContent = '0'; rLam.value = 0.20; rLamv.textContent = '0.20'; drawR(); };
    rJust.onclick = () => { rDeg.value = 3; rDegv.textContent = '3'; rLam.value = 0.00; rLamv.textContent = '0.00'; drawR(); };
    rOver.onclick = () => { rDeg.value = 12; rDegv.textContent = '12'; rLam.value = 0.00; rLamv.textContent = '0.00'; drawR(); };

    let R_DATA = [], R_TRAIN = [], R_VAL = [], R_TEST = [];

    function ftrue(x) { return Math.sin(2 * Math.PI * x * 0.9) + 0.5 * Math.cos(2 * Math.PI * x * 0.3); }
    function genR() {
      const n = +rN.value, nz = +rNoise.value; R_DATA = [];
      for (let i = 0; i < n; i++) {
        const x = rnd(0, 1);
        const y = ftrue(x) + randn() * nz;
        R_DATA.push({ x, y });
      }
      const [tr, val, te] = split3Index(R_DATA.length, 0.6, 0.2); R_TRAIN = tr.map(i => R_DATA[i]); R_VAL = val.map(i => R_DATA[i]); R_TEST = te.map(i => R_DATA[i]);
      drawR();
    }

    function designMatrix(x, deg) {
      const X = Array(x.length).fill(0).map(() => Array(deg + 1).fill(0));
      for (let i = 0; i < x.length; i++) { let v = 1; for (let d = 0; d <= deg; d++) { X[i][d] = v; v *= x[i]; } }
      return X;
    }
    function matT(A) { const m = A.length, n = A[0].length; const T = Array(n).fill(0).map(() => Array(m).fill(0)); for (let i = 0; i < m; i++)for (let j = 0; j < n; j++)T[j][i] = A[i][j]; return T; }
    function matMul(A, B) { const m = A.length, n = A[0].length, k = B[0].length; const C = Array(m).fill(0).map(() => Array(k).fill(0)); for (let i = 0; i < m; i++)for (let j = 0; j < k; j++) { let s = 0; for (let t = 0; t < n; t++) s += A[i][t] * B[t][j]; C[i][j] = s; } return C; }
    function eye(n) { const I = Array(n).fill(0).map(() => Array(n).fill(0)); for (let i = 0; i < n; i++)I[i][i] = 1; return I; }
    function add(A, B) { const m = A.length, n = A[0].length; const C = Array(m).fill(0).map(() => Array(n).fill(0)); for (let i = 0; i < m; i++)for (let j = 0; j < n; j++)C[i][j] = A[i][j] + B[i][j]; return C; }

    function solve(A, b) { // Gauss-Jordan 求解 Ax=b
      const n = A.length; const M = Array(n).fill(0).map((_, i) => [...A[i], b[i][0]]);
      for (let col = 0; col < n; col++) {
        let piv = col; for (let r = col + 1; r < n; r++) if (Math.abs(M[r][col]) > Math.abs(M[piv][col])) piv = r;
        if (Math.abs(M[piv][col]) < 1e-12) return null; // 奇异
        [M[col], M[piv]] = [M[piv], M[col]];
        const div = M[col][col]; for (let j = col; j <= n; j++) M[col][j] /= div;
        for (let r = 0; r < n; r++) { if (r === col) continue; const f = M[r][col]; if (Math.abs(f) < 1e-12) continue; for (let j = col; j <= n; j++) M[r][j] -= f * M[col][j]; }
      }
      const x = Array(n).fill(0).map((_, i) => [M[i][n]]); return x;
    }

    function polyFit(x, y, deg, lam) {
      const X = designMatrix(x, deg); const XT = matT(X);
      const XTX = matMul(XT, X); // (deg+1)*(deg+1)
      const npar = deg + 1; const reg = eye(npar).map((row, i) => row.map((v, j) => (i === j ? lam : 0)));
      const A = add(XTX, reg);
      const XTy = matMul(XT, y.map(v => [v]));
      const w = solve(A, XTy); // (deg+1)*1
      return w ? w.map(r => r[0]) : null;
    }

    function polyPred(w, x) { let y = 0, v = 1; for (let i = 0; i < w.length; i++) { y += w[i] * v; v *= x; } return y; }
    function rmse(yhat, y) { let s = 0; for (let i = 0; i < y.length; i++) { const e = yhat[i] - y[i]; s += e * e; } return Math.sqrt(s / y.length); }

    function diagnoseReg(ltr, lva, lte) {
      const gapV = lva - ltr, gapT = lte - ltr;
      if (ltr > 0.55 && lva > 0.55 && lte > 0.55) return { text: '欠拟合：三组 RMSE 都高（容量太小或正则过强）', cls: 'warn' };
      if (gapV > 0.15 && gapT > 0.15) return { text: '过拟合：训练低、验证/测试高（曲线跟着噪声摆动）', cls: 'bad' };
      if (lva < 0.22 && Math.abs(gapV) < 0.08 && Math.abs(lte - lva) < 0.08) return { text: '拟合得当：各集 RMSE 低且接近', cls: 'good' };
      return { text: gapV > 0.15 ? '可能过拟合：降阶或提高 λ' : '偏欠拟合：升阶或降低 λ/加特征', cls: gapV > 0.15 ? 'bad' : 'warn' };
    }

    function drawR() {
      const w = rCanvas.width, h = rCanvas.height; rc.clearRect(0, 0, w, h);
      const pad = 36; const x2px = x => pad + x * (w - 2 * pad); const y2px = y => h - pad - (y - (-2)) * (h - 2 * pad) / (4);
      // 背景网格
      rc.strokeStyle = '#152550'; rc.lineWidth = 1;
      for (let x = pad; x < w - pad; x += 40) { rc.beginPath(); rc.moveTo(x, pad); rc.lineTo(x, h - pad); rc.stroke() }
      for (let y = pad; y < h - pad; y += 40) { rc.beginPath(); rc.moveTo(pad, y); rc.lineTo(w - pad, y); rc.stroke() }
      rc.strokeStyle = '#2a3f78'; rc.strokeRect(pad, pad, w - 2 * pad, h - 2 * pad);

      // 真实函数
      rc.beginPath(); rc.strokeStyle = '#324a86'; rc.lineWidth = 1.5; let first = true;
      for (let i = 0; i <= 300; i++) { const x = i / 300; const y = ftrue(x); const X = x2px(x), Y = y2px(y); if (first) { rc.moveTo(X, Y); first = false; } else rc.lineTo(X, Y); }
      rc.stroke();

      // 拟合
      const deg = +rDeg.value, lam = +rLam.value;
      const xtr = R_TRAIN.map(p => p.x), ytr = R_TRAIN.map(p => p.y);
      const wcoef = polyFit(xtr, ytr, deg, lam);

      // 曲线
      if (wcoef) {
        rc.beginPath(); rc.strokeStyle = '#5ad6ff'; rc.lineWidth = 2.2; first = true;
        for (let i = 0; i <= 300; i++) { const x = i / 300; const y = polyPred(wcoef, x); const X = x2px(x), Y = y2px(y); if (first) { rc.moveTo(X, Y); first = false; } else rc.lineTo(X, Y); }
        rc.stroke();
      }

      // 点：训练/验证/测试
      for (const p of R_TRAIN) { rc.beginPath(); rc.fillStyle = '#5ad6ff'; rc.arc(x2px(p.x), y2px(p.y), 3.5, 0, Math.PI * 2); rc.fill(); }
      for (const p of R_VAL) { rc.beginPath(); rc.strokeStyle = '#ffb84d'; rc.arc(x2px(p.x), y2px(p.y), 5.5, 0, Math.PI * 2); rc.stroke(); }
      for (const p of R_TEST) { rc.beginPath(); rc.strokeStyle = '#c0a6ff'; rc.moveTo(x2px(p.x), y2px(p.y) - 5); rc.lineTo(x2px(p.x) - 4, y2px(p.y) + 4); rc.lineTo(x2px(p.x) + 4, y2px(p.y) + 4); rc.closePath(); rc.stroke(); }

      // 指标
      if (wcoef) {
        const yhat_tr = R_TRAIN.map(p => polyPred(wcoef, p.x));
        const yhat_va = R_VAL.map(p => polyPred(wcoef, p.x));
        const yhat_te = R_TEST.map(p => polyPred(wcoef, p.x));
        const ltr = rmse(yhat_tr, R_TRAIN.map(p => p.y));
        const lva = rmse(yhat_va, R_VAL.map(p => p.y));
        const lte = rmse(yhat_te, R_TEST.map(p => p.y));
        document.getElementById('r-train-loss').textContent = ltr.toFixed(3);
        document.getElementById('r-val-loss').textContent = lva.toFixed(3);
        document.getElementById('r-test-loss').textContent = lte.toFixed(3);
        const d = diagnoseReg(ltr, lva, lte); const box = document.getElementById('r-diagnose'); box.classList.remove('good', 'warn', 'bad'); box.classList.add(d.cls); document.getElementById('r-dia-text').textContent = d.text;
        document.getElementById('r-explain').textContent = `当 d=${deg}、λ=${lam.toFixed(2)} 时：训练 ${ltr.toFixed(3)}，验证 ${lva.toFixed(3)}，测试 ${lte.toFixed(3)}。${d.text}`;
      }

      // 损失-复杂度曲线（度数 0..16）
      const Degs = [...Array(17).keys()];
      const ltrA = [], lvaA = [], lteA = [];
      for (const dg of Degs) { const w = polyFit(R_TRAIN.map(p => p.x), R_TRAIN.map(p => p.y), dg, +rLam.value); if (!w) { ltrA.push(NaN); lvaA.push(NaN); lteA.push(NaN); continue; } ltrA.push(rmse(R_TRAIN.map(p => polyPred(w, p.x)), R_TRAIN.map(p => p.y))); lvaA.push(rmse(R_VAL.map(p => polyPred(w, p.x)), R_VAL.map(p => p.y))); lteA.push(rmse(R_TEST.map(p => polyPred(w, p.x)), R_TEST.map(p => p.y))); }
      const minIdx = lvaA.indexOf(Math.min(...lvaA));
      const curIdx = +rDeg.value;
      drawLossLines(rLossPlot, Degs, ltrA, lvaA, lteA, x => x, curIdx, minIdx);
    }

    genR();

    /************** ④ 小测 **************/
    const Q = [
      { q: '关于“泛化能力”的正确描述是：', opts: ['只要求在训练集上表现好', '对未见样本也能给出合理输出', '指模型容量越大越好', '指训练轮数越多越好'], a: 1, hint: '泛化=应对未见样本，而非只看训练集' },
      { q: '下列哪项最符合“过拟合”的典型现象？', opts: ['训练损失低，验证/测试损失高', '训练/验证/测试损失都很高', '训练/验证/测试损失都很低', '验证损失低、训练损失高'], a: 0, hint: '过拟合 ⇒ 高方差 ⇒ 训练好但验证差' },
      { q: '下列哪项最符合“欠拟合”的典型现象？', opts: ['模型过于复杂，参数太多', '模型过于简单，三组（训练/验证/测试）指标都偏高', '训练损失远低于验证损失', '只要增加数据量即可立刻消除'], a: 1, hint: '欠拟合 ⇒ 高偏差 ⇒ 三集指标都不好' },
      { q: '关于国产红酒示例，哪种做法最可能导致“过拟合”？', opts: ['仅用“酒精 11%～13%”一个狭窄规则来判好坏', '综合酒精、糖分、pH 等多个指标并在验证集上调参', '使用更大的样本并做交叉验证', '对连续指标进行标准化后再训练'], a: 0, hint: '规则过窄 = 容量不足但容易记住局部特征 ⇒ 过拟合' },
      { q: '在构建识别红酒质量的模型时，哪种做法最有助于在“过拟合/欠拟合”之间取得平衡？', opts: ['只在训练集上不断调参直到训练误差接近 0', '盲目提高模型阶数或降低 k 值以追求训练集完美拟合', '把噪声当成稳定特征加入模型', '合理划分训练/验证/测试，并在验证集上选择合适复杂度与正则或早停'], a: 3, hint: '平衡泛化 = 验证集选模型 + 正则/早停' },
      { q: '“泛化能力”就是指模型在训练集上达到最低损失。', opts: ['√', '×'], a: 1, hint: '泛化是指面对新样本仍表现好，而不是只看训练集' },
      { q: '过拟合通常表现为高方差；欠拟合通常表现为高偏差。', opts: ['√', '×'], a: 0, hint: '偏差-方差分解：过拟合=高方差，欠拟合=高偏差' },
      { q: '用线性函数去拟合明显呈抛物线趋势的数据，容易出现欠拟合。', opts: ['√', '×'], a: 0, hint: '模型过于简单 ⇒ 难以表达真实关系 ⇒ 欠拟合' },
      { q: '当验证损失随模型复杂度出现“先降后升”的 U 形曲线时，拐点左侧更可能欠拟合、右侧更可能过拟合。', opts: ['√', '×'], a: 0, hint: 'U 型曲线规律：左侧容量不足，右侧容量过大' },
      { q: '在红酒示例中，只给出酒精>11%、糖分=5%、pH=3～4 三个特征就一定能避免欠拟合。', opts: ['√', '×'], a: 1, hint: '特征不足或不合理仍会导致欠拟合' }
    ];

    let qi = 0;
    const qbox = document.getElementById('qbox');
    const qhint = document.getElementById('qhint');
    const nextQ = document.getElementById('nextQ');

    // 新增统计变量
    let quizCorrect = 0;
    let quizTotal = 0;

    // 更新统计显示
    function updateQuizStats() {
      document.getElementById('quizCorrect').textContent = quizCorrect;
      document.getElementById('quizTotal').textContent = quizTotal;
      document.getElementById('quizAcc').textContent = quizTotal > 0 ? Math.round(quizCorrect / quizTotal * 100) + '%' : '0%';
    }

    function renderQ() {
      // 如果全部题目已答完，显示完成提示
      if (qi >= Q.length) {
        qbox.innerHTML = '<div class="legend" style="font-size:18px;line-height:2">🎉 本轮测试已完成</div>';
        qhint.textContent = '做得好！你可以切换到其他选项卡继续学习。';
        nextQ.style.display = 'none';
        return;
      }
      const it = Q[qi];
      qbox.innerHTML = '';
      qhint.textContent = '';
      nextQ.style.display = 'none';
      const h = document.createElement('div');
      h.className = 'legend';
      h.textContent = 'Q' + (qi + 1) + '：' + it.q;
      qbox.appendChild(h);
      it.opts.forEach((t, i) => {
        const el = document.createElement('div');
        el.className = 'opt';
        el.textContent = t;
        el.onclick = () => {
          quizTotal++;
          if (i === it.a) {
            el.classList.add('correct');
            qhint.textContent = '✅ 正确！' + it.hint;
            quizCorrect++;
          } else {
            el.classList.add('wrong');
            qhint.textContent = '❌ 再想想：' + it.hint;
          }
          [...qbox.querySelectorAll('.opt')].forEach(e => e.onclick = null);
          nextQ.style.display = 'inline-block';
          updateQuizStats(); // 答题后更新统计
        };
        qbox.appendChild(el);
      });
    }

    renderQ();
    updateQuizStats(); // 初始化统计显示

    nextQ.onclick = () => {
      qi++;
      renderQ();
    }
  </script>
</body>

</html>